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 FormTemplate from 'component/common/FormTemplate/FormTemplate';
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
import type { ISegment } from 'interfaces/segment';
import { useState } from 'react';
import { formatStrategyName } from 'utils/strategyNames';
import { BuiltInStrategies, formatStrategyName } from 'utils/strategyNames';
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 { MilestoneStrategySegment } from './MilestoneStrategySegment';
import { MilestoneStrategyConstraints } from './MilestoneStrategyConstraints';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
@ -34,6 +51,15 @@ const StyledTitle = styled('h1')(({ theme }) => ({
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 }) => ({
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
@ -46,6 +72,10 @@ const StyledTab = styled(Tab)(({ theme }) => ({
width: '100px',
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
const StyledContentDiv = styled('div')(({ theme }) => ({
position: 'relative',
display: 'flex',
@ -58,11 +88,34 @@ const StyledContentDiv = styled('div')(({ theme }) => ({
height: '100%',
}));
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
position: 'relative',
marginTop: theme.spacing(3.5),
}));
const StyledTargetingHeader = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
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 {
milestoneId: string | undefined;
onCancel: () => void;
@ -81,10 +134,25 @@ export const ReleasePlanTemplateAddStrategyForm = ({
}: IReleasePlanTemplateAddStrategyFormProps) => {
const [addStrategy, setAddStrategy] = useState(strategy);
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) => {
setActiveTab(newValue);
};
const getTargetingCount = () => {
const constraintCount = addStrategy?.constraints?.length || 0;
const segmentCount = segments?.length || 0;
return constraintCount + segmentCount;
};
const updateParameter = (name: string, value: string) => {
setAddStrategy(
produce((draft) => {
@ -108,10 +176,6 @@ export const ReleasePlanTemplateAddStrategyForm = ({
onAddStrategy(milestoneId, addStrategy);
};
if (!strategy) {
return null;
}
return (
<FormTemplate
modal
@ -127,9 +191,35 @@ export const ReleasePlanTemplateAddStrategyForm = ({
)}
</StyledTitle>
</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}>
<StyledTab label='General' />
<Tab label={<Typography>Targeting</Typography>} />
<Tab
label={
<Typography>
Targeting
<StyledBadge>{getTargetingCount()}</StyledBadge>
</Typography>
}
/>
</StyledTabs>
<StyledContentDiv>
{activeTab === 0 && (
@ -140,6 +230,14 @@ export const ReleasePlanTemplateAddStrategyForm = ({
updateParameter('title', title)
}
/>
<MilestoneStrategyType
strategy={addStrategy}
strategyDefinition={strategyDefinition}
parameters={addStrategy.parameters}
updateParameter={updateParameter}
errors={errors}
/>
</>
)}
{activeTab === 1 && (
@ -147,6 +245,18 @@ export const ReleasePlanTemplateAddStrategyForm = ({
<StyledTargetingHeader>
Segmentation and constraints allow you to set
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
the specified preconditions.
</StyledTargetingHeader>
@ -158,6 +268,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({
variant='contained'
color='primary'
type='submit'
disabled={!hasValidConstraints || errors.hasFormErrors()}
onClick={addStrategyToMilestone}
>
Save strategy