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

refactor: new constraints style (#9363)

Refactored styles for strategy evaluation parameters. New look for constraints etc
This commit is contained in:
Tymoteusz Czech 2025-02-28 10:49:23 +01:00 committed by GitHub
parent 97fd1c0fec
commit a6cfcea029
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 871 additions and 401 deletions

View File

@ -56,6 +56,7 @@ const StyledLink = styled(Link)(({ theme }) => ({
textDecoration: 'underline',
},
}));
const StyledText = styled('span', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled: boolean | null }>(({ theme, disabled }) => ({

View File

@ -9,9 +9,7 @@ const Chip = styled('div')(({ theme }) => ({
transform: 'translateY(-50%)',
lineHeight: 1,
borderRadius: theme.shape.borderRadiusLarge,
fontWeight: 'bold',
backgroundColor: theme.palette.background.alternative,
color: theme.palette.primary.contrastText,
backgroundColor: theme.palette.secondary.border,
left: theme.spacing(4),
}));

View File

@ -1,60 +1,52 @@
import { Chip, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import type { FC } from 'react';
import { StrategyEvaluationItem } from '../StrategyEvaluationItem/StrategyEvaluationItem';
import type { ConstraintSchema } from 'openapi';
import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription';
import { StrategyEvaluationChip } from '../StrategyEvaluationChip/StrategyEvaluationChip';
import { styled, Tooltip } from '@mui/material';
interface IConstraintItemProps {
value: string[];
text: string;
}
const Inverted: FC = () => (
<Tooltip title='NOT (operator is negated)' arrow>
<StrategyEvaluationChip label='≠' />
</Tooltip>
);
const StyledContainer = styled('div')(({ theme }) => ({
width: '100%',
padding: theme.spacing(2, 3),
borderRadius: theme.shape.borderRadiusMedium,
background: theme.palette.background.default,
border: `1px solid ${theme.palette.divider}`,
const Operator: FC<{ label: ConstraintSchema['operator'] }> = ({ label }) => (
<Tooltip title={label} arrow>
<StrategyEvaluationChip label={formatOperatorDescription(label)} />
</Tooltip>
);
const CaseInsensitive: FC = () => (
<Tooltip title='Case sensitive' arrow>
<StrategyEvaluationChip label={<s>Aa</s>} />
</Tooltip>
);
const StyledOperatorGroup = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
}));
const StyledParagraph = styled('p')(({ theme }) => ({
display: 'inline',
margin: theme.spacing(0.5, 0),
maxWidth: '95%',
textAlign: 'center',
wordBreak: 'break-word',
}));
export const ConstraintItem: FC<ConstraintSchema> = ({
caseInsensitive,
contextName,
inverted,
operator,
value,
values,
}) => {
const items = value ? [value, ...(values || [])] : values || [];
const StyledChip = styled(Chip)(({ theme }) => ({
margin: theme.spacing(0.5),
}));
export const ConstraintItem = ({ value, text }: IConstraintItemProps) => {
return (
<StyledContainer>
<ConditionallyRender
condition={value.length === 0}
show={<p>No {text}s added yet.</p>}
elseShow={
<div>
<StyledParagraph>
{value.length}{' '}
{value.length > 1 ? `${text}s` : text} will get
access.
</StyledParagraph>
{value.map((v: string) => (
<StyledChip
key={v}
label={
<StringTruncator
maxWidth='300'
text={v}
maxLength={50}
/>
}
/>
))}
</div>
}
/>
</StyledContainer>
<StrategyEvaluationItem type='Constraint' values={items}>
{contextName}
<StyledOperatorGroup>
{inverted ? <Inverted /> : null}
<Operator label={operator} />
{caseInsensitive ? <CaseInsensitive /> : null}
</StyledOperatorGroup>
</StrategyEvaluationItem>
);
};

View File

@ -0,0 +1,60 @@
import { Chip, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
interface IConstraintItemProps {
value: string[];
text: string;
}
const StyledContainer = styled('div')(({ theme }) => ({
width: '100%',
padding: theme.spacing(2, 3),
borderRadius: theme.shape.borderRadiusMedium,
background: theme.palette.background.default,
border: `1px solid ${theme.palette.divider}`,
}));
const StyledParagraph = styled('p')(({ theme }) => ({
display: 'inline',
margin: theme.spacing(0.5, 0),
maxWidth: '95%',
textAlign: 'center',
wordBreak: 'break-word',
}));
const StyledChip = styled(Chip)(({ theme }) => ({
margin: theme.spacing(0.5),
}));
export const ConstraintItem = ({ value, text }: IConstraintItemProps) => {
return (
<StyledContainer>
<ConditionallyRender
condition={value.length === 0}
show={<p>No {text}s added yet.</p>}
elseShow={
<div>
<StyledParagraph>
{value.length}{' '}
{value.length > 1 ? `${text}s` : text} will get
access.
</StyledParagraph>
{value.map((v: string) => (
<StyledChip
key={v}
label={
<StringTruncator
maxWidth='300'
text={v}
maxLength={50}
/>
}
/>
))}
</div>
}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,372 @@
import { type FC, Fragment, useMemo } from 'react';
import { Alert, Box, Chip, Link, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { ConstraintItem } from './ConstraintItem/LegacyConstraintItem';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { FeatureOverviewSegment } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment';
import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
import {
parseParameterNumber,
parseParameterString,
parseParameterStrings,
} from 'utils/parseParameter';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { Badge } from 'component/common/Badge/Badge';
import type { CreateFeatureStrategySchema } from 'openapi';
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
import { BuiltInStrategies } from 'utils/strategyNames';
interface IStrategyExecutionProps {
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
displayGroupId?: boolean;
}
const StyledContainer = styled(Box, {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled?: boolean | null }>(({ theme, disabled }) => ({
'& p, & span, & h1, & h2, & h3, & h4, & h5, & h6': {
color: disabled ? theme.palette.neutral.main : 'inherit',
},
'.constraint-icon-container': {
backgroundColor: disabled
? theme.palette.neutral.border
: theme.palette.primary.light,
borderRadius: '50%',
},
'.constraint-icon': {
fill: disabled
? theme.palette.neutral.light
: theme.palette.common.white,
},
}));
const CustomStrategyDeprecationWarning = () => (
<Alert severity='warning' sx={{ mb: 2 }}>
Custom strategies are deprecated and may be removed in a future major
version. Consider rewriting this strategy as a predefined strategy with{' '}
<Link
href={
'https://docs.getunleash.io/reference/activation-strategies#constraints'
}
target='_blank'
variant='body2'
>
constraints.
</Link>
</Alert>
);
const NoItems: FC = () => (
<Box sx={{ px: 3, color: 'text.disabled' }}>
This strategy does not have constraints or parameters.
</Box>
);
const StyledValueContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(2, 3),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
background: theme.palette.background.default,
}));
const StyledValueSeparator = styled('span')(({ theme }) => ({
color: theme.palette.neutral.main,
}));
export const StrategyExecution: FC<IStrategyExecutionProps> = ({
strategy,
displayGroupId = false,
}) => {
const { parameters, constraints = [] } = strategy;
const stickiness = parameters?.stickiness;
const explainStickiness =
typeof stickiness === 'string' && stickiness !== 'default';
const { strategies } = useStrategies();
const { segments } = useSegments();
const strategySegments = segments?.filter((segment) => {
return strategy.segments?.includes(segment.id);
});
const definition = strategies.find((strategyDefinition) => {
return strategyDefinition.name === strategy.name;
});
const parametersList = useMemo(() => {
if (!parameters || definition?.editable) return null;
return Object.keys(parameters).map((key) => {
switch (key) {
case 'rollout':
case 'Rollout': {
const percentage = parseParameterNumber(parameters[key]);
const badgeType = strategy.disabled ? 'neutral' : 'success';
return (
<StyledValueContainer
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ mr: 2 }}>
<PercentageCircle
percentage={percentage}
size='2rem'
disabled={strategy.disabled}
/>
</Box>
<div>
<Badge color={badgeType}>{percentage}%</Badge>{' '}
<span>of your base</span>{' '}
<span>
{explainStickiness ? (
<>
with <strong>{stickiness}</strong>
</>
) : (
''
)}{' '}
</span>
<span>
{constraints.length > 0
? 'who match constraints'
: ''}{' '}
is included.
</span>
</div>
{displayGroupId && parameters.groupId && (
<Box
sx={(theme) => ({
ml: 1,
color: theme.palette.info.contrastText,
})}
>
<Badge color='info'>
GroupId: {parameters.groupId}
</Badge>
</Box>
)}
</StyledValueContainer>
);
}
case 'userIds':
case 'UserIds': {
const users = parseParameterStrings(parameters[key]);
return (
<ConstraintItem key={key} value={users} text='user' />
);
}
case 'hostNames':
case 'HostNames': {
const hosts = parseParameterStrings(parameters[key]);
return (
<ConstraintItem key={key} value={hosts} text={'host'} />
);
}
case 'IPs': {
const IPs = parseParameterStrings(parameters[key]);
return <ConstraintItem key={key} value={IPs} text={'IP'} />;
}
case 'stickiness':
case 'groupId':
return null;
default:
return null;
}
});
}, [parameters, definition, constraints, strategy.disabled]);
const customStrategyList = useMemo(() => {
if (!parameters || !definition?.editable) return null;
const isSetTo = (
<StyledValueSeparator>{' is set to '}</StyledValueSeparator>
);
return definition?.parameters.map((param) => {
const { type, name } = { ...param };
if (!type || !name || parameters[name] === undefined) {
return null;
}
const nameItem = (
<StringTruncator maxLength={15} maxWidth='150' text={name} />
);
switch (param?.type) {
case 'list': {
const values = parseParameterStrings(parameters[name]);
return values.length > 0 ? (
<StyledValueContainer>
{nameItem}{' '}
<StyledValueSeparator>
has {values.length}{' '}
{values.length > 1 ? `items` : 'item'}:{' '}
{values.map((item: string) => (
<Chip
key={item}
label={
<StringTruncator
maxWidth='300'
text={item}
maxLength={50}
/>
}
sx={{ mr: 0.5 }}
/>
))}
</StyledValueSeparator>
</StyledValueContainer>
) : null;
}
case 'percentage': {
const percentage = parseParameterNumber(parameters[name]);
return parameters[name] !== '' ? (
<StyledValueContainer
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ mr: 2 }}>
<PercentageCircle
percentage={percentage}
size='2rem'
/>
</Box>
<div>
{nameItem}
{isSetTo}
<Badge color='success'>{percentage}%</Badge>
</div>
</StyledValueContainer>
) : null;
}
case 'boolean':
return parameters[name] === 'true' ||
parameters[name] === 'false' ? (
<StyledValueContainer>
<StringTruncator
maxLength={15}
maxWidth='150'
text={name}
/>
{isSetTo}
<Badge
color={
parameters[name] === 'true'
? 'success'
: 'error'
}
>
{parameters[name]}
</Badge>
</StyledValueContainer>
) : null;
case 'string': {
const value = parseParameterString(parameters[name]);
return typeof parameters[name] !== 'undefined' ? (
<StyledValueContainer>
{nameItem}
<ConditionallyRender
condition={value === ''}
show={
<StyledValueSeparator>
{' is an empty string'}
</StyledValueSeparator>
}
elseShow={
<>
{isSetTo}
<StringTruncator
maxWidth='300'
text={value}
maxLength={50}
/>
</>
}
/>
</StyledValueContainer>
) : null;
}
case 'number': {
const number = parseParameterNumber(parameters[name]);
return parameters[name] !== '' && number !== undefined ? (
<StyledValueContainer>
{nameItem}
{isSetTo}
<StringTruncator
maxWidth='300'
text={String(number)}
maxLength={50}
/>
</StyledValueContainer>
) : null;
}
case 'default':
return null;
}
return null;
});
}, [parameters, definition]);
if (!parameters) {
return <NoItems />;
}
const listItems = [
strategySegments && strategySegments.length > 0 && (
<FeatureOverviewSegment
segments={strategySegments}
disabled={strategy.disabled}
/>
),
constraints.length > 0 && (
<ConstraintAccordionList
constraints={constraints}
showLabel={false}
/>
),
strategy.name === 'default' && (
<>
<StyledValueContainer sx={{ width: '100%' }}>
The standard strategy is <Badge color='success'>ON</Badge>{' '}
for all users.
</StyledValueContainer>
</>
),
...(parametersList ?? []),
...(customStrategyList ?? []),
].filter(Boolean);
return (
<>
<ConditionallyRender
condition={
!BuiltInStrategies.includes(strategy.name || 'default')
}
show={<CustomStrategyDeprecationWarning />}
/>
<ConditionallyRender
condition={listItems.length > 0}
show={
<StyledContainer disabled={Boolean(strategy.disabled)}>
{listItems.map((item, index) => (
<Fragment key={index}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
{item}
</Fragment>
))}
</StyledContainer>
}
elseShow={<NoItems />}
/>
</>
);
};

View File

@ -0,0 +1,18 @@
import { forwardRef } from 'react';
import type { ChipProps } from '@mui/material';
import { Chip, styled } from '@mui/material';
const StyledChip = styled(Chip)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadius}px`,
padding: theme.spacing(0.25, 0),
fontSize: theme.fontSizes.smallerBody,
height: 'auto',
background: theme.palette.secondary.light,
border: `1px solid ${theme.palette.secondary.border}`,
color: theme.palette.secondary.dark,
fontWeight: theme.typography.fontWeightBold,
}));
export const StrategyEvaluationChip = forwardRef<HTMLDivElement, ChipProps>(
(props, ref) => <StyledChip size='small' ref={ref} {...props} />,
);

View File

@ -0,0 +1,57 @@
import { Chip, type ChipProps, styled } from '@mui/material';
import type { FC, ReactNode } from 'react';
type StrategyItemProps = {
type?: ReactNode;
children?: ReactNode;
values?: string[];
};
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
fontSize: theme.typography.body2.fontSize,
}));
const StyledType = styled('span')(({ theme }) => ({
display: 'block',
fontSize: theme.fontSizes.smallerBody,
fontWeight: theme.typography.fontWeightBold,
color: theme.palette.text.secondary,
width: theme.spacing(10),
}));
const StyledValuesGroup = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
}));
const StyledValue = styled(({ ...props }: ChipProps) => (
<Chip size='small' {...props} />
))(({ theme }) => ({
padding: theme.spacing(0.5),
background: theme.palette.background.elevation1,
}));
/**
* Abstract building block for a list of constraints, segments and other items inside a strategy
*/
export const StrategyEvaluationItem: FC<StrategyItemProps> = ({
type,
children,
values,
}) => (
<StyledContainer>
<StyledType>{type}</StyledType>
{children}
{values && values?.length > 0 ? (
<StyledValuesGroup>
{values?.map((value, index) => (
<StyledValue key={`${value}#${index}`} label={value} />
))}
</StyledValuesGroup>
) : null}
</StyledContainer>
);

View File

@ -0,0 +1,18 @@
import { styled } from '@mui/material';
const StyledAnd = styled('div')(({ theme }) => ({
position: 'absolute',
top: theme.spacing(-0.5),
left: theme.spacing(2),
transform: 'translateY(-50%)',
padding: theme.spacing(0.75, 1),
lineHeight: 1,
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.primary,
background: theme.palette.background.application,
borderRadius: theme.shape.borderRadiusLarge,
}));
export const StrategyEvaluationSeparator = () => (
<StyledAnd role='separator'>AND</StyledAnd>
);

View File

@ -1,372 +1,110 @@
import { type FC, Fragment, useMemo } from 'react';
import { Alert, Box, Chip, Link, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { ConstraintItem } from './ConstraintItem/ConstraintItem';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { FeatureOverviewSegment } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment';
import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
import {
parseParameterNumber,
parseParameterString,
parseParameterStrings,
} from 'utils/parseParameter';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { Badge } from 'component/common/Badge/Badge';
import { Children, isValidElement, type FC, type ReactNode } from 'react';
import { styled } from '@mui/material';
import type { CreateFeatureStrategySchema } from 'openapi';
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
import { BuiltInStrategies } from 'utils/strategyNames';
import { useUiFlag } from 'hooks/useUiFlag';
import { StrategyExecution as LegacyStrategyExecution } from './LegacyStrategyExecution';
import { ConstraintItem } from './ConstraintItem/ConstraintItem';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { objectId } from 'utils/objectId';
import { StrategyEvaluationSeparator } from './StrategyEvaluationSeparator/StrategyEvaluationSeparator';
import { useCustomStrategyParameters } from './hooks/useCustomStrategyParameters';
import { useStrategyParameters } from './hooks/useStrategyParameters';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { SegmentItem } from 'component/common/SegmentItem/SegmentItem';
interface IStrategyExecutionProps {
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
displayGroupId?: boolean;
}
const FilterContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'grayscale',
})<{ grayscale: boolean }>(({ grayscale }) =>
grayscale ? { filter: 'grayscale(1)', opacity: 0.67 } : {},
);
const StyledContainer = styled(Box, {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled?: boolean | null }>(({ theme, disabled }) => ({
'& p, & span, & h1, & h2, & h3, & h4, & h5, & h6': {
color: disabled ? theme.palette.neutral.main : 'inherit',
},
'.constraint-icon-container': {
backgroundColor: disabled
? theme.palette.neutral.border
: theme.palette.primary.light,
borderRadius: '50%',
},
'.constraint-icon': {
fill: disabled
? theme.palette.neutral.light
: theme.palette.common.white,
const StyledList = styled('ul')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
listStyle: 'none',
padding: 0,
margin: 0,
'&.disabled-strategy': {
filter: 'grayscale(1)',
opacity: 0.67,
},
gap: theme.spacing(1),
}));
const CustomStrategyDeprecationWarning = () => (
<Alert severity='warning' sx={{ mb: 2 }}>
Custom strategies are deprecated and may be removed in a future major
version. Consider rewriting this strategy as a predefined strategy with{' '}
<Link
href={
'https://docs.getunleash.io/reference/activation-strategies#constraints'
}
target='_blank'
variant='body2'
>
constraints.
</Link>
</Alert>
);
const NoItems: FC = () => (
<Box sx={{ px: 3, color: 'text.disabled' }}>
This strategy does not have constraints or parameters.
</Box>
);
const StyledValueContainer = styled(Box)(({ theme }) => ({
const StyledListItem = styled('li')(({ theme }) => ({
position: 'relative',
padding: theme.spacing(2, 3),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
background: theme.palette.background.default,
}));
const StyledValueSeparator = styled('span')(({ theme }) => ({
color: theme.palette.neutral.main,
}));
const List: FC<{ children: ReactNode }> = ({ children }) => {
const result: ReactNode[] = [];
Children.forEach(children, (child, index) => {
if (isValidElement(child)) {
result.push(
<ListItem key={index}>
{index > 0 ? (
<StrategyEvaluationSeparator key={`${index}-divider`} />
) : null}
{child}
</ListItem>,
);
}
});
export const StrategyExecution: FC<IStrategyExecutionProps> = ({
return <StyledList>{result}</StyledList>;
};
const ListItem: FC<{ children: ReactNode }> = ({ children }) => (
<StyledListItem>{children}</StyledListItem>
);
type StrategyExecutionProps = {
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
displayGroupId?: boolean;
};
export const StrategyExecution: FC<StrategyExecutionProps> = ({
strategy,
displayGroupId = false,
}) => {
const { parameters, constraints = [] } = strategy;
const stickiness = parameters?.stickiness;
const explainStickiness =
typeof stickiness === 'string' && stickiness !== 'default';
const { strategies } = useStrategies();
const { segments } = useSegments();
const strategySegments = segments?.filter((segment) => {
return strategy.segments?.includes(segment.id);
});
const { isCustomStrategy, customStrategyParameters: customStrategyItems } =
useCustomStrategyParameters(strategy, strategies);
const strategyParameters = useStrategyParameters(strategy, displayGroupId);
const { constraints } = strategy;
const strategySegments = segments?.filter((segment) =>
strategy.segments?.includes(segment.id),
);
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
const definition = strategies.find((strategyDefinition) => {
return strategyDefinition.name === strategy.name;
});
const parametersList = useMemo(() => {
if (!parameters || definition?.editable) return null;
return Object.keys(parameters).map((key) => {
switch (key) {
case 'rollout':
case 'Rollout': {
const percentage = parseParameterNumber(parameters[key]);
const badgeType = strategy.disabled ? 'neutral' : 'success';
return (
<StyledValueContainer
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ mr: 2 }}>
<PercentageCircle
percentage={percentage}
size='2rem'
disabled={strategy.disabled}
/>
</Box>
<div>
<Badge color={badgeType}>{percentage}%</Badge>{' '}
<span>of your base</span>{' '}
<span>
{explainStickiness ? (
<>
with <strong>{stickiness}</strong>
</>
) : (
''
)}{' '}
</span>
<span>
{constraints.length > 0
? 'who match constraints'
: ''}{' '}
is included.
</span>
</div>
{displayGroupId && parameters.groupId && (
<Box
sx={(theme) => ({
ml: 1,
color: theme.palette.info.contrastText,
})}
>
<Badge color='info'>
GroupId: {parameters.groupId}
</Badge>
</Box>
)}
</StyledValueContainer>
);
}
case 'userIds':
case 'UserIds': {
const users = parseParameterStrings(parameters[key]);
return (
<ConstraintItem key={key} value={users} text='user' />
);
}
case 'hostNames':
case 'HostNames': {
const hosts = parseParameterStrings(parameters[key]);
return (
<ConstraintItem key={key} value={hosts} text={'host'} />
);
}
case 'IPs': {
const IPs = parseParameterStrings(parameters[key]);
return <ConstraintItem key={key} value={IPs} text={'IP'} />;
}
case 'stickiness':
case 'groupId':
return null;
default:
return null;
}
});
}, [parameters, definition, constraints, strategy.disabled]);
const customStrategyList = useMemo(() => {
if (!parameters || !definition?.editable) return null;
const isSetTo = (
<StyledValueSeparator>{' is set to '}</StyledValueSeparator>
if (!flagOverviewRedesign) {
return (
<LegacyStrategyExecution
strategy={strategy}
displayGroupId={displayGroupId}
/>
);
return definition?.parameters.map((param) => {
const { type, name } = { ...param };
if (!type || !name || parameters[name] === undefined) {
return null;
}
const nameItem = (
<StringTruncator maxLength={15} maxWidth='150' text={name} />
);
switch (param?.type) {
case 'list': {
const values = parseParameterStrings(parameters[name]);
return values.length > 0 ? (
<StyledValueContainer>
{nameItem}{' '}
<StyledValueSeparator>
has {values.length}{' '}
{values.length > 1 ? `items` : 'item'}:{' '}
{values.map((item: string) => (
<Chip
key={item}
label={
<StringTruncator
maxWidth='300'
text={item}
maxLength={50}
/>
}
sx={{ mr: 0.5 }}
/>
))}
</StyledValueSeparator>
</StyledValueContainer>
) : null;
}
case 'percentage': {
const percentage = parseParameterNumber(parameters[name]);
return parameters[name] !== '' ? (
<StyledValueContainer
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ mr: 2 }}>
<PercentageCircle
percentage={percentage}
size='2rem'
/>
</Box>
<div>
{nameItem}
{isSetTo}
<Badge color='success'>{percentage}%</Badge>
</div>
</StyledValueContainer>
) : null;
}
case 'boolean':
return parameters[name] === 'true' ||
parameters[name] === 'false' ? (
<StyledValueContainer>
<StringTruncator
maxLength={15}
maxWidth='150'
text={name}
/>
{isSetTo}
<Badge
color={
parameters[name] === 'true'
? 'success'
: 'error'
}
>
{parameters[name]}
</Badge>
</StyledValueContainer>
) : null;
case 'string': {
const value = parseParameterString(parameters[name]);
return typeof parameters[name] !== 'undefined' ? (
<StyledValueContainer>
{nameItem}
<ConditionallyRender
condition={value === ''}
show={
<StyledValueSeparator>
{' is an empty string'}
</StyledValueSeparator>
}
elseShow={
<>
{isSetTo}
<StringTruncator
maxWidth='300'
text={value}
maxLength={50}
/>
</>
}
/>
</StyledValueContainer>
) : null;
}
case 'number': {
const number = parseParameterNumber(parameters[name]);
return parameters[name] !== '' && number !== undefined ? (
<StyledValueContainer>
{nameItem}
{isSetTo}
<StringTruncator
maxWidth='300'
text={String(number)}
maxLength={50}
/>
</StyledValueContainer>
) : null;
}
case 'default':
return null;
}
return null;
});
}, [parameters, definition]);
if (!parameters) {
return <NoItems />;
}
const listItems = [
strategySegments && strategySegments.length > 0 && (
<FeatureOverviewSegment
segments={strategySegments}
disabled={strategy.disabled}
/>
),
constraints.length > 0 && (
<ConstraintAccordionList
constraints={constraints}
showLabel={false}
/>
),
strategy.name === 'default' && (
<>
<StyledValueContainer sx={{ width: '100%' }}>
The standard strategy is <Badge color='success'>ON</Badge>{' '}
for all users.
</StyledValueContainer>
</>
),
...(parametersList ?? []),
...(customStrategyList ?? []),
].filter(Boolean);
return (
<>
<ConditionallyRender
condition={
!BuiltInStrategies.includes(strategy.name || 'default')
}
show={<CustomStrategyDeprecationWarning />}
/>
<ConditionallyRender
condition={listItems.length > 0}
show={
<StyledContainer disabled={Boolean(strategy.disabled)}>
{listItems.map((item, index) => (
<Fragment key={index}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
{item}
</Fragment>
))}
</StyledContainer>
}
elseShow={<NoItems />}
/>
</>
<FilterContainer grayscale={strategy.disabled === true}>
<List>
{strategySegments?.map((segment) => (
<SegmentItem segment={segment} />
))}
{constraints?.map((constraint, index) => (
<ConstraintItem
key={`${objectId(constraint)}-${index}`}
{...constraint}
/>
))}
{isCustomStrategy ? customStrategyItems : strategyParameters}
</List>
</FilterContainer>
);
};

View File

@ -0,0 +1,123 @@
import { useMemo } from 'react';
import { Truncator } from 'component/common/Truncator/Truncator';
import {
parseParameterNumber,
parseParameterString,
parseParameterStrings,
} from 'utils/parseParameter';
import { StrategyEvaluationItem } from '../StrategyEvaluationItem/StrategyEvaluationItem';
import { StrategyEvaluationChip } from '../StrategyEvaluationChip/StrategyEvaluationChip';
import type {
CreateFeatureStrategySchema,
StrategySchema,
StrategySchemaParametersItem,
} from 'openapi';
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
export const useCustomStrategyParameters = (
strategy: CreateFeatureStrategySchema | IFeatureStrategyPayload,
strategies: StrategySchema[],
) => {
const { parameters } = strategy;
const definition = useMemo(
() =>
strategies.find((strategyDefinition) => {
return strategyDefinition.name === strategy.name;
}),
[strategies, strategy.name],
);
const isCustomStrategy = definition?.editable;
const mapCustomStrategies = (
param: StrategySchemaParametersItem,
index: number,
) => {
if (!parameters || !param.name) return null;
const { type, name } = param;
const typeItem = <Truncator title={name}>{name}</Truncator>;
const key = `${type}${index}`;
switch (type) {
case 'list': {
const values = parseParameterStrings(parameters[name]);
if (!values || values.length === 0) {
return null;
}
return (
<StrategyEvaluationItem
key={key}
type={typeItem}
values={values}
>
{values.length === 1
? 'has 1 item:'
: `has ${values.length} items:`}
</StrategyEvaluationItem>
);
}
case 'percentage': {
const value = parseParameterNumber(parameters[name]);
return (
<StrategyEvaluationItem key={key} type={typeItem}>
is set to <StrategyEvaluationChip label={`${value}%`} />
</StrategyEvaluationItem>
);
}
case 'boolean': {
const value = parameters[name];
return (
<StrategyEvaluationItem key={key} type={typeItem}>
is set to <StrategyEvaluationChip label={value} />
</StrategyEvaluationItem>
);
}
case 'string': {
const value = parseParameterString(parameters[name]);
return (
<StrategyEvaluationItem
key={key}
type={typeItem}
values={value === '' ? undefined : [value]}
>
{value === '' ? 'is an empty string' : 'is set to'}
</StrategyEvaluationItem>
);
}
case 'number': {
const value = parseParameterNumber(parameters[name]);
return (
<StrategyEvaluationItem
key={key}
type={typeItem}
values={[`${value}`]}
>
is a number set to
</StrategyEvaluationItem>
);
}
case 'default':
return null;
}
return null;
};
return useMemo(
() => ({
isCustomStrategy,
customStrategyParameters: isCustomStrategy
? definition?.parameters
?.map(mapCustomStrategies)
.filter(Boolean)
: [],
}),
[definition, isCustomStrategy, parameters],
);
};

View File

@ -0,0 +1,92 @@
import { type FC, useMemo } from 'react';
import { StrategyEvaluationChip } from '../StrategyEvaluationChip/StrategyEvaluationChip';
import {
parseParameterNumber,
parseParameterStrings,
} from 'utils/parseParameter';
import { StrategyEvaluationItem } from '../StrategyEvaluationItem/StrategyEvaluationItem';
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
import type { CreateFeatureStrategySchema } from 'openapi';
const RolloutParameter: FC<{
value?: string | number;
parameters?: (
| IFeatureStrategyPayload
| CreateFeatureStrategySchema
)['parameters'];
hasConstraints?: boolean;
displayGroupId?: boolean;
}> = ({ value, parameters, hasConstraints, displayGroupId }) => {
const percentage = parseParameterNumber(value);
const explainStickiness =
typeof parameters?.stickiness === 'string' &&
parameters?.stickiness !== 'default';
const stickiness = explainStickiness ? (
<>
with <strong>{parameters.stickiness}</strong>
</>
) : (
''
);
return (
<StrategyEvaluationItem type='Rollout %'>
<StrategyEvaluationChip label={`${percentage}%`} /> of your base{' '}
{stickiness}
<span>
{hasConstraints ? 'who match constraints ' : ' '}
is included.
</span>
{/* TODO: displayGroupId */}
</StrategyEvaluationItem>
);
};
export const useStrategyParameters = (
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema,
displayGroupId?: boolean,
) => {
const { constraints } = strategy;
const { parameters } = strategy;
const hasConstraints = Boolean(constraints?.length);
const parameterKeys = parameters ? Object.keys(parameters) : [];
const mapPredefinedStrategies = (key: string) => {
const type = key.toLocaleLowerCase();
if (type === 'rollout') {
return (
<RolloutParameter
key={key}
value={parameters?.[key]}
parameters={parameters}
hasConstraints={hasConstraints}
displayGroupId={displayGroupId}
/>
);
}
if (['userids', 'hostnames', 'ips'].includes(type)) {
return (
<StrategyEvaluationItem
key={key}
type={key}
values={parseParameterStrings(parameters?.[key])}
/>
);
}
return null;
};
return useMemo(
() =>
[
...parameterKeys.map(mapPredefinedStrategies),
strategy.name === 'default' ? (
<RolloutParameter value={100} />
) : null,
].filter(Boolean),
[parameters, hasConstraints, displayGroupId],
);
};

View File

@ -108,6 +108,7 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => {
const isOverflowing = tagLabel.length > 25;
return (
<StyledTag
key={tagLabel}
label={
<Tooltip
key={tagLabel}