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

feat: release plan template strategy types, constraints, segments (#8861)

This commit is contained in:
David Leek 2024-11-27 08:20:46 +01:00 committed by GitHub
parent ca5c03ed17
commit 219006c856
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 532 additions and 7 deletions

View File

@ -0,0 +1,66 @@
import { FeatureStrategyConstraintAccordionList } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList';
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
import type { IConstraint } from 'interfaces/strategy';
import { useEffect } from 'react';
interface IMilestoneStrategyConstraintsProps {
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>;
setStrategy: React.Dispatch<
React.SetStateAction<Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>>
>;
}
const filterConstraints = (constraint: any) => {
if (
constraint.hasOwnProperty('values') &&
(!constraint.hasOwnProperty('value') || constraint.value === '')
) {
return constraint.values && constraint.values.length > 0;
}
if (constraint.hasOwnProperty('value')) {
return constraint.value !== '';
}
};
export const MilestoneStrategyConstraints = ({
strategy,
setStrategy,
}: IMilestoneStrategyConstraintsProps) => {
useEffect(() => {
return () => {
if (!strategy.constraints) {
return;
}
// If the component is unmounting we want to remove all constraints that do not have valid single value or
// valid multivalues
setStrategy((prev) => ({
...prev,
constraints: prev.constraints?.filter(filterConstraints),
}));
};
}, []);
const constraints = strategy.constraints || [];
const setConstraints = (value: React.SetStateAction<IConstraint[]>) => {
setStrategy((prev) => {
return {
...prev,
constraints:
value instanceof Function
? value(prev.constraints || [])
: value,
};
});
};
return (
<FeatureStrategyConstraintAccordionList
constraints={constraints}
setConstraints={setConstraints}
showCreateButton={true}
/>
);
};

View File

@ -0,0 +1,98 @@
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import type { ISegment } from 'interfaces/segment';
import { Box, styled, Typography } from '@mui/material';
import { SegmentDocsStrategyWarning } from 'component/segments/SegmentDocs';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import {
AutocompleteBox,
type IAutocompleteBoxOption,
} from 'component/common/AutocompleteBox/AutocompleteBox';
import { MilestoneStrategySegmentList } from './MilestoneStrategySegmentList';
const StyledHelpIconBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
}));
interface IMilestoneStrategySegmentProps {
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
}
export const MilestoneStrategySegment = ({
segments: selectedSegments,
setSegments: setSelectedSegments,
}: IMilestoneStrategySegmentProps) => {
const { segments: allSegments } = useSegments();
const { strategySegmentsLimit } = useSegmentLimits();
const atStrategySegmentsLimit: boolean = Boolean(
strategySegmentsLimit &&
selectedSegments.length >= strategySegmentsLimit,
);
if (!allSegments || allSegments.length === 0) {
return null;
}
const unusedSegments = allSegments.filter((segment) => {
return !selectedSegments.find((selected) => selected.id === segment.id);
});
const autocompleteOptions = unusedSegments.map((segment) => ({
value: String(segment.id),
label: segment.name,
}));
const onChange = ([option]: IAutocompleteBoxOption[]) => {
const selectedSegment = allSegments.find((segment) => {
return String(segment.id) === option.value;
});
if (selectedSegment) {
setSelectedSegments((prev) => [...prev, selectedSegment]);
}
};
return (
<>
<StyledHelpIconBox>
<Typography>Segments</Typography>
<HelpIcon
htmlTooltip
tooltip={
<Box>
<Typography variant='body2'>
Segments are reusable sets of constraints that
can be defined once and reused across feature
toggle configurations. You can create a segment
on the global or the project level. Read more
about segments{' '}
<a
href='https://docs.getunleash.io/reference/segments'
target='_blank'
rel='noopener noreferrer'
>
here
</a>
</Typography>
</Box>
}
/>
</StyledHelpIconBox>
{atStrategySegmentsLimit && <SegmentDocsStrategyWarning />}
<AutocompleteBox
label='Select segments'
options={autocompleteOptions}
onChange={onChange}
disabled={atStrategySegmentsLimit}
/>
<MilestoneStrategySegmentList
segments={selectedSegments}
setSegments={setSelectedSegments}
/>
</>
);
};

View File

@ -0,0 +1,80 @@
import { Fragment, useState } from 'react';
import type { ISegment } from 'interfaces/segment';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeatureStrategySegmentChip } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip';
import { SegmentItem } from 'component/common/SegmentItem/SegmentItem';
import { styled } from '@mui/material';
const StyledList = styled('div')(({ theme }) => ({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(1),
}));
const StyledSelectedSegmentsLabel = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const StyledAnd = styled('p')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
padding: theme.spacing(0.75, 1),
display: 'block',
marginTop: 'auto',
marginBottom: 'auto',
alignItems: 'center',
borderRadius: theme.shape.borderRadius,
lineHeight: 1,
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.elevation2,
}));
type IMilestoneStrategySegmentListProps = {
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
};
export const MilestoneStrategySegmentList = ({
segments,
setSegments,
}: IMilestoneStrategySegmentListProps) => {
const [preview, setPreview] = useState<ISegment>();
const lastSegmentIndex = segments.length - 1;
if (segments.length === 0) {
console.log('segments.length === 0');
return null;
}
return (
<>
<ConditionallyRender
condition={segments && segments.length > 0}
show={
<StyledSelectedSegmentsLabel>
Selected Segments
</StyledSelectedSegmentsLabel>
}
/>
<StyledList>
{segments.map((segment, i) => (
<Fragment key={segment.id}>
<FeatureStrategySegmentChip
segment={segment}
setSegments={setSegments}
preview={preview}
setPreview={setPreview}
/>
<ConditionallyRender
condition={i < lastSegmentIndex}
show={<StyledAnd>AND</StyledAnd>}
/>
</Fragment>
))}
</StyledList>
<ConditionallyRender
condition={Boolean(preview)}
show={() => <SegmentItem segment={preview!} isExpanded />}
/>
</>
);
};

View File

@ -0,0 +1,59 @@
import type { IFormErrors } from 'hooks/useFormErrors';
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
import type { IStrategy } from 'interfaces/strategy';
import { MilestoneStrategyTypeFlexible } from './MilestoneStrategyTypeFlexible';
import GeneralStrategy from 'component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy';
import UserWithIdStrategy from 'component/feature/StrategyTypes/UserWithIdStrategy/UserWithId';
import DefaultStrategy from 'component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy';
interface IMilestoneStrategyTypeProps {
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>;
strategyDefinition?: IStrategy;
parameters: IReleasePlanMilestoneStrategy['parameters'];
updateParameter: (field: string, value: string) => void;
errors: IFormErrors;
}
export const MilestoneStrategyType = ({
strategy,
strategyDefinition,
parameters,
updateParameter,
errors,
}: IMilestoneStrategyTypeProps) => {
if (!strategyDefinition) {
return null;
}
switch (strategy.name) {
case 'default':
return <DefaultStrategy strategyDefinition={strategyDefinition} />;
case 'flexibleRollout':
return (
<MilestoneStrategyTypeFlexible
parameters={parameters}
updateParameter={updateParameter}
errors={errors}
editable={true}
/>
);
case 'userWithId':
return (
<UserWithIdStrategy
editable={true}
parameters={strategy.parameters ?? {}}
updateParameter={updateParameter}
errors={errors}
/>
);
default:
return (
<GeneralStrategy
strategyDefinition={strategyDefinition}
parameters={strategy.parameters ?? {}}
updateParameter={updateParameter}
editable={true}
errors={errors}
/>
);
}
};

View File

@ -0,0 +1,111 @@
import { Box, styled } from '@mui/material';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import RolloutSlider from 'component/feature/StrategyTypes/RolloutSlider/RolloutSlider';
import type { IFormErrors } from 'hooks/useFormErrors';
import type { IFeatureStrategyParameters } from 'interfaces/strategy';
import { useMemo } from 'react';
import {
parseParameterNumber,
parseParameterString,
} from 'utils/parseParameter';
import Input from 'component/common/Input/Input';
interface IMilestoneStrategyTypeFlexibleProps {
parameters: IFeatureStrategyParameters;
updateParameter: (field: string, value: string) => void;
editable: boolean;
errors?: IFormErrors;
}
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.palette.background.elevation1,
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadiusMedium}px`,
}));
const StyledOuterBox = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(1),
display: 'flex',
width: '100%',
justifyContent: 'space-between',
}));
const StyledInnerBox1 = styled(Box)(({ theme }) => ({
width: '50%',
marginRight: theme.spacing(0.5),
}));
const StyledInnerBox2 = styled(Box)(({ theme }) => ({
width: '50%',
marginLeft: theme.spacing(0.5),
}));
const DEFAULT_STICKINESS = 'default';
export const MilestoneStrategyTypeFlexible = ({
parameters,
updateParameter,
editable,
errors,
}: IMilestoneStrategyTypeFlexibleProps) => {
const updateRollout = (e: Event, value: number | number[]) => {
updateParameter('rollout', value.toString());
};
const rollout =
parameters.rollout !== undefined
? parseParameterNumber(parameters.rollout)
: 100;
const stickiness = useMemo(() => {
if (!parameters.stickiness) {
updateParameter('stickiness', DEFAULT_STICKINESS);
}
return parseParameterString(parameters.stickiness);
}, [parameters.stickiness]);
const groupId = parseParameterString(parameters.groupId);
return (
<StyledBox>
<RolloutSlider
name='Rollout'
value={rollout}
disabled={!editable}
onChange={updateRollout}
/>
<StyledOuterBox>
<StyledInnerBox1>
<StickinessSelect
label='Stickiness'
value={stickiness}
editable={editable}
onChange={(e) =>
updateParameter('stickiness', e.target.value)
}
/>
</StyledInnerBox1>
<StyledInnerBox2>
<Input
label='groupId'
sx={{ width: '100%' }}
id='groupId-input'
value={groupId}
disabled={!editable}
onChange={(e) =>
updateParameter(
'groupId',
parseParameterString(e.target.value),
)
}
error={Boolean(errors?.getFormError('groupId'))}
helperText={errors?.getFormError('groupId')}
/>
</StyledInnerBox2>
</StyledOuterBox>
</StyledBox>
);
};

View File

@ -1,11 +1,28 @@
import { Box, Button, styled, Tab, Tabs, Typography } from '@mui/material'; import {
Box,
Button,
styled,
Alert,
Link,
Tab,
Tabs,
Typography,
Divider,
} from '@mui/material';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans'; import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
import type { ISegment } from 'interfaces/segment';
import { useState } from 'react'; import { useState } from 'react';
import { formatStrategyName } from 'utils/strategyNames'; import { BuiltInStrategies, formatStrategyName } from 'utils/strategyNames';
import { MilestoneStrategyTitle } from './MilestoneStrategyTitle'; import { MilestoneStrategyTitle } from './MilestoneStrategyTitle';
import { MilestoneStrategyType } from './MilestoneStrategyType';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import { useFormErrors } from 'hooks/useFormErrors';
import produce from 'immer'; import produce from 'immer';
import { MilestoneStrategySegment } from './MilestoneStrategySegment';
import { MilestoneStrategyConstraints } from './MilestoneStrategyConstraints';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
const StyledCancelButton = styled(Button)(({ theme }) => ({ const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3), marginLeft: theme.spacing(3),
@ -34,6 +51,15 @@ const StyledTitle = styled('h1')(({ theme }) => ({
paddingBottom: theme.spacing(2), paddingBottom: theme.spacing(2),
})); }));
const StyledAlertBox = styled(Box)(({ theme }) => ({
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
'& > *': {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
}));
const StyledTabs = styled(Tabs)(({ theme }) => ({ const StyledTabs = styled(Tabs)(({ theme }) => ({
borderTop: `1px solid ${theme.palette.divider}`, borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`, borderBottom: `1px solid ${theme.palette.divider}`,
@ -46,6 +72,10 @@ const StyledTab = styled(Tab)(({ theme }) => ({
width: '100px', width: '100px',
})); }));
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
const StyledContentDiv = styled('div')(({ theme }) => ({ const StyledContentDiv = styled('div')(({ theme }) => ({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
@ -58,11 +88,34 @@ const StyledContentDiv = styled('div')(({ theme }) => ({
height: '100%', height: '100%',
})); }));
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
position: 'relative',
marginTop: theme.spacing(3.5),
}));
const StyledTargetingHeader = styled('div')(({ theme }) => ({ const StyledTargetingHeader = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
marginTop: theme.spacing(1.5), marginTop: theme.spacing(1.5),
})); }));
const StyledDivider = styled(Divider)(({ theme }) => ({
width: '100%',
}));
const StyledDividerContent = styled(Box)(({ theme }) => ({
padding: theme.spacing(0.75, 1),
color: theme.palette.text.primary,
fontSize: theme.fontSizes.smallerBody,
backgroundColor: theme.palette.background.elevation2,
borderRadius: theme.shape.borderRadius,
width: '45px',
position: 'absolute',
top: '-10px',
left: 'calc(50% - 45px)',
lineHeight: 1,
}));
interface IReleasePlanTemplateAddStrategyFormProps { interface IReleasePlanTemplateAddStrategyFormProps {
milestoneId: string | undefined; milestoneId: string | undefined;
onCancel: () => void; onCancel: () => void;
@ -81,10 +134,25 @@ export const ReleasePlanTemplateAddStrategyForm = ({
}: IReleasePlanTemplateAddStrategyFormProps) => { }: IReleasePlanTemplateAddStrategyFormProps) => {
const [addStrategy, setAddStrategy] = useState(strategy); const [addStrategy, setAddStrategy] = useState(strategy);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [segments, setSegments] = useState<ISegment[]>([]);
const { strategyDefinition } = useStrategy(strategy?.name);
const hasValidConstraints = useConstraintsValidation(strategy?.constraints);
const errors = useFormErrors();
if (!strategy || !addStrategy || !strategyDefinition) {
return null;
}
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setActiveTab(newValue); setActiveTab(newValue);
}; };
const getTargetingCount = () => {
const constraintCount = addStrategy?.constraints?.length || 0;
const segmentCount = segments?.length || 0;
return constraintCount + segmentCount;
};
const updateParameter = (name: string, value: string) => { const updateParameter = (name: string, value: string) => {
setAddStrategy( setAddStrategy(
produce((draft) => { produce((draft) => {
@ -108,10 +176,6 @@ export const ReleasePlanTemplateAddStrategyForm = ({
onAddStrategy(milestoneId, addStrategy); onAddStrategy(milestoneId, addStrategy);
}; };
if (!strategy) {
return null;
}
return ( return (
<FormTemplate <FormTemplate
modal modal
@ -127,9 +191,35 @@ export const ReleasePlanTemplateAddStrategyForm = ({
)} )}
</StyledTitle> </StyledTitle>
</StyledHeaderBox> </StyledHeaderBox>
{!BuiltInStrategies.includes(strategy.name || 'default') && (
<StyledAlertBox>
<Alert severity='warning'>
Custom strategies are deprecated. We recommend not
adding them to any templates going forward and using the
predefined strategies like Gradual rollout with{' '}
<Link
href={
'https://docs.getunleash.io/reference/strategy-constraints'
}
target='_blank'
variant='body2'
>
constraints
</Link>{' '}
instead.
</Alert>
</StyledAlertBox>
)}
<StyledTabs value={activeTab} onChange={handleChange}> <StyledTabs value={activeTab} onChange={handleChange}>
<StyledTab label='General' /> <StyledTab label='General' />
<Tab label={<Typography>Targeting</Typography>} /> <Tab
label={
<Typography>
Targeting
<StyledBadge>{getTargetingCount()}</StyledBadge>
</Typography>
}
/>
</StyledTabs> </StyledTabs>
<StyledContentDiv> <StyledContentDiv>
{activeTab === 0 && ( {activeTab === 0 && (
@ -140,6 +230,14 @@ export const ReleasePlanTemplateAddStrategyForm = ({
updateParameter('title', title) updateParameter('title', title)
} }
/> />
<MilestoneStrategyType
strategy={addStrategy}
strategyDefinition={strategyDefinition}
parameters={addStrategy.parameters}
updateParameter={updateParameter}
errors={errors}
/>
</> </>
)} )}
{activeTab === 1 && ( {activeTab === 1 && (
@ -147,6 +245,18 @@ export const ReleasePlanTemplateAddStrategyForm = ({
<StyledTargetingHeader> <StyledTargetingHeader>
Segmentation and constraints allow you to set Segmentation and constraints allow you to set
filters on your strategies, so that they will only filters on your strategies, so that they will only
<MilestoneStrategySegment
segments={segments}
setSegments={setSegments}
/>
<StyledBox>
<StyledDivider />
<StyledDividerContent>AND</StyledDividerContent>
</StyledBox>
<MilestoneStrategyConstraints
strategy={addStrategy}
setStrategy={setAddStrategy}
/>
be evaluated for users and applications that match be evaluated for users and applications that match
the specified preconditions. the specified preconditions.
</StyledTargetingHeader> </StyledTargetingHeader>
@ -158,6 +268,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({
variant='contained' variant='contained'
color='primary' color='primary'
type='submit' type='submit'
disabled={!hasValidConstraints || errors.hasFormErrors()}
onClick={addStrategyToMilestone} onClick={addStrategyToMilestone}
> >
Save strategy Save strategy