1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +02:00

Feat(UI): new strategy variant chips (#9507)

- new way of showing strategy variants
- fixed wrapping issue in strategy editing, for a lot of variants
defined (`SplitPreviewSlider.tsx` change)
- aligned difference between API and manually added types
This commit is contained in:
Tymoteusz Czech 2025-03-13 11:27:45 +01:00 committed by GitHub
parent 5ad3178590
commit 863788d7b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 169 additions and 84 deletions

View File

@ -274,7 +274,7 @@ describe('Strategy change conflict detection', () => {
name: 'variant1',
weight: 1000,
payload: {
type: 'string',
type: 'string' as const,
value: 'beaty',
},
stickiness: 'userId',

View File

@ -12,11 +12,19 @@ const StyledContainer = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
alignItems: 'center',
fontSize: theme.typography.body2.fontSize,
padding: theme.spacing(2, 3),
margin: theme.spacing(2, 3),
}));
const StyledContent = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
flexWrap: 'wrap',
alignItems: 'center',
}));
const StyledType = styled('span')(({ theme }) => ({
display: 'block',
flexShrink: 0,
fontSize: theme.fontSizes.smallerBody,
fontWeight: theme.typography.fontWeightBold,
color: theme.palette.text.secondary,
@ -46,13 +54,15 @@ export const StrategyEvaluationItem: FC<StrategyItemProps> = ({
}) => (
<StyledContainer>
<StyledType>{type}</StyledType>
{children}
{values && values?.length > 0 ? (
<StyledValuesGroup>
{values?.map((value, index) => (
<StyledValue key={`${value}#${index}`} label={value} />
))}
</StyledValuesGroup>
) : null}
<StyledContent>
{children}
{values && values?.length > 0 ? (
<StyledValuesGroup>
{values?.map((value, index) => (
<StyledValue key={`${value}#${index}`} label={value} />
))}
</StyledValuesGroup>
) : null}
</StyledContent>
</StyledContainer>
);

View File

@ -15,7 +15,7 @@ interface ISidebarModalProps {
const TRANSITION_DURATION = 250;
const ModalContentWrapper = styled('div')({
const ModalContentWrapper = styled('div')(({ theme }) => ({
position: 'absolute',
top: 0,
right: 0,
@ -24,7 +24,8 @@ const ModalContentWrapper = styled('div')({
maxWidth: '98vw',
overflow: 'auto',
boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)',
});
background: theme.palette.background.paper,
}));
const FixedWidthContentWrapper = styled(ModalContentWrapper)({
width: 1300,

View File

@ -0,0 +1,46 @@
import { StrategyEvaluationChip } from 'component/common/ConstraintsList/StrategyEvaluationChip/StrategyEvaluationChip';
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem';
import type { ParametersSchema, StrategyVariantSchema } from 'openapi';
import type { FC } from 'react';
import { parseParameterNumber } from 'utils/parseParameter';
import { RolloutVariants } from './RolloutVariants/RolloutVariants';
export const RolloutParameter: FC<{
value: string;
parameters?: ParametersSchema;
hasConstraints?: boolean;
variants?: StrategyVariantSchema[];
displayGroupId?: boolean;
}> = ({ value, parameters, hasConstraints, variants, 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>
{displayGroupId && parameters?.groupId ? (
<StrategyEvaluationChip
label={`groupId: ${parameters?.groupId}`}
/>
) : null}
</StrategyEvaluationItem>
<RolloutVariants variants={variants} />
</>
);
};

View File

@ -0,0 +1,71 @@
import { styled } from '@mui/material';
import { StrategyEvaluationChip } from 'component/common/ConstraintsList/StrategyEvaluationChip/StrategyEvaluationChip';
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import type { StrategyVariantSchema } from 'openapi';
import type { FC } from 'react';
const StyledVariantChip = styled(StrategyEvaluationChip)<{ order: number }>(
({ theme, order }) => {
const variantColor =
theme.palette.variants[order % theme.palette.variants.length];
return {
borderRadius: theme.shape.borderRadiusExtraLarge,
border: 'none',
color: theme.palette.text.primary,
background:
// TODO: adjust theme.palette.variants
theme.mode === 'dark'
? `hsl(from ${variantColor} h calc(s - 30) calc(l - 45))`
: `hsl(from ${variantColor} h s calc(l + 5))`,
fontWeight: theme.typography.fontWeightRegular,
};
},
);
const StyledPayloadHeader = styled('div')(({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
marginBottom: theme.spacing(1),
}));
export const RolloutVariants: FC<{
variants?: StrategyVariantSchema[];
}> = ({ variants }) => {
if (!variants?.length) {
return null;
}
return (
<StrategyEvaluationItem type={`Variants (${variants.length})`}>
{variants.map((variant, i) => (
<HtmlTooltip
arrow
title={
variant.payload?.value ? (
<div>
<StyledPayloadHeader>
Payload:
</StyledPayloadHeader>
<code>{variant.payload?.value}</code>
</div>
) : null
}
key={variant.name}
>
<StyledVariantChip
key={variant.name}
order={i}
label={
<>
<span>
{variant.weight / 10}% {variant.name}
</span>
</>
}
/>
</HtmlTooltip>
))}
</StrategyEvaluationItem>
);
};

View File

@ -1,6 +1,6 @@
import type { FC } from 'react';
import { styled } from '@mui/material';
import type { CreateFeatureStrategySchema } from 'openapi';
import type { FeatureStrategySchema } from 'openapi';
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
import { useUiFlag } from 'hooks/useUiFlag';
import { StrategyExecution as LegacyStrategyExecution } from './LegacyStrategyExecution';
@ -20,7 +20,7 @@ const FilterContainer = styled('div', {
);
type StrategyExecutionProps = {
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
strategy: IFeatureStrategyPayload | FeatureStrategySchema;
displayGroupId?: boolean;
};
@ -32,7 +32,10 @@ export const StrategyExecution: FC<StrategyExecutionProps> = ({
const { segments } = useSegments();
const { isCustomStrategy, customStrategyParameters: customStrategyItems } =
useCustomStrategyParameters(strategy, strategies);
const strategyParameters = useStrategyParameters(strategy, displayGroupId);
const strategyParameters = useStrategyParameters(
strategy as FeatureStrategySchema,
displayGroupId,
);
const { constraints } = strategy;
const strategySegments = segments?.filter((segment) =>
strategy.segments?.includes(segment.id),
@ -52,7 +55,7 @@ export const StrategyExecution: FC<StrategyExecutionProps> = ({
<FilterContainer grayscale={strategy.disabled === true}>
<ConstraintsList>
{strategySegments?.map((segment) => (
<SegmentItem segment={segment} />
<SegmentItem segment={segment} key={segment.id} />
))}
{constraints?.map((constraint, index) => (
<ConstraintItem

View File

@ -1,71 +1,30 @@
import { type FC, useMemo } from 'react';
import { StrategyEvaluationChip } from 'component/common/ConstraintsList/StrategyEvaluationChip/StrategyEvaluationChip';
import {
parseParameterNumber,
parseParameterStrings,
} from 'utils/parseParameter';
import { useMemo } from 'react';
import { parseParameterStrings } from 'utils/parseParameter';
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/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>
{displayGroupId && parameters?.groupId ? (
<StrategyEvaluationChip
label={`groupId: ${parameters?.groupId}`}
/>
) : null}
</StrategyEvaluationItem>
);
};
import type { FeatureStrategySchema } from 'openapi';
import { RolloutParameter } from '../RolloutParameter/RolloutParameter';
export const useStrategyParameters = (
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema,
strategy: Partial<FeatureStrategySchema>,
displayGroupId?: boolean,
) => {
const { constraints } = strategy;
const { constraints, variants } = strategy;
const { parameters } = strategy;
const hasConstraints = Boolean(constraints?.length);
const parameterKeys = parameters ? Object.keys(parameters) : [];
const mapPredefinedStrategies = (key: string) => {
const type = key.toLocaleLowerCase();
const value = parameters?.[key] || '';
if (type === 'rollout') {
return (
<RolloutParameter
key={key}
value={parameters?.[key]}
value={value}
parameters={parameters}
hasConstraints={hasConstraints}
displayGroupId={displayGroupId}
variants={variants}
/>
);
}
@ -75,7 +34,7 @@ export const useStrategyParameters = (
<StrategyEvaluationItem
key={key}
type={key}
values={parseParameterStrings(parameters?.[key])}
values={parseParameterStrings(value)}
/>
);
}
@ -88,7 +47,7 @@ export const useStrategyParameters = (
[
...parameterKeys.map(mapPredefinedStrategies),
strategy.name === 'default' ? (
<RolloutParameter value={100} />
<RolloutParameter value='100' />
) : null,
].filter(Boolean),
[parameters, hasConstraints, displayGroupId],

View File

@ -1,8 +1,6 @@
import type { DragEventHandler, FC, ReactNode } from 'react';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
import SplitPreviewSlider from 'component/feature/StrategyTypes/SplitPreviewSlider/SplitPreviewSlider';
import { Box } from '@mui/material';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
type StrategyItemProps = {
@ -29,16 +27,6 @@ export const StrategyItem: FC<StrategyItemProps> = ({
headerItemsRight={headerItemsRight}
>
<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} />
))}
</StrategyItemContainer>
);
};

View File

@ -149,7 +149,7 @@ const payloadOptions = [
{ key: 'number', label: 'number' },
];
const EMPTY_PAYLOAD = { type: 'string', value: '' };
const EMPTY_PAYLOAD = { type: 'string' as const, value: '' };
enum ErrorField {
NAME = 'name',
@ -438,7 +438,7 @@ export const VariantForm = ({
clearError(ErrorField.PAYLOAD);
setPayload((payload) => ({
...payload,
type: e.target.value,
type: e.target.value as typeof payload.type,
}));
}}
/>

View File

@ -48,6 +48,7 @@ const StyledVariantBoxContainer = styled(Box)(() => ({
display: 'flex',
alignItems: 'center',
marginLeft: 'auto',
flexWrap: 'wrap',
}));
const StyledVariantBox = styled(Box, {

View File

@ -19,7 +19,7 @@ test('should render variants', async () => {
weight: 1000,
weightType: 'variable' as const,
payload: {
type: 'string',
type: 'string' as const,
value: 'variantValue',
},
},

View File

@ -99,6 +99,9 @@ export interface IFeatureEnvironmentWithCrEnabled extends IFeatureEnvironment {
crEnabled?: boolean;
}
/**
* @deprecated use `StrategyVariantSchema` from openapi
*/
export interface IFeatureVariant {
name: string;
stickiness: string;
@ -114,7 +117,7 @@ export interface IOverride {
}
export interface IPayload {
type: string;
type: 'string' | 'number' | 'json' | 'csv';
value: string;
}

View File

@ -22,6 +22,9 @@ export interface IFeatureStrategyParameters {
[key: string]: string | number | undefined;
}
/**
* @deprecated use `FeatureStrategySchema` from openapi
*/
export interface IFeatureStrategyPayload {
id?: string;
name?: string;